Skip to content

Map generation improvements: modular framework and twelve new generators - #240

Merged
genixpro merged 147 commits into
masterfrom
codex/modular-map-generators
Sep 14, 2026
Merged

genixpro merged 147 commits into
masterfrom
codex/modular-map-generators

Conversation

@genixpro

@genixpro genixpro commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Map generation was spread across central dispatch code, overloaded descriptor fields, global random state and duplicated helpers, which made generators hard to add, compare or trust. This PR makes map generation modular, adds twelve new generators, makes starts measurably fairer, gives every generator tunable resource amounts and on/off switches, and gives the custom game lobby better control over random maps.

What changes

Framework. Each generator is a registered module with named controls, a revision, and optional request and world validation. The lobby, map editor, compatibility adapter, study tools and tests all read the same definitions. GenerationService validates the request, generates, validates the finished world, scores it, and keeps the best of five seeded rolls. Randomness comes only from named per-generation streams. See docs/map-generators/.

Catalog. 20 playable generators plus editor-only Uniform. New:

  • Contested commons: home islands around a large shared island.
  • Maze: a pen-and-paper maze of grass passages, stone-and-water walls and sand roads, with every colony in its own cul-de-sac.
  • Fjord continent: fjords between colonies, a central lake that can connect to them, and outlying resource islands.
  • Watershed: a branching river network into a delta, with fertile floodplains, dry uplands and sand fords.
  • Stone highlands: grass valleys walled by stone ridges, with passes and ponds.
  • Symmetric arena: exactly symmetric starts for 2, 4 or 8 colonies around a moated orchard.
  • Ring world: one continent belt wrapping the torus, so every colony has exactly two neighbours.
  • City states: a large shared commons in the middle ringed by a strait, and round it one big walled home per colony, each joined to the commons by a single stone-lined causeway. Every roll differs in its coasts, its home layout (lake, creek and ford, ridges and passes, marsh, sand flanks) and the heart of its commons (lake, crag, island, delta, forest), and every home is the same wedge turned round the centre, so any colony count is fair by rotation. Played and approved over four iterations.
  • Tidal flats: grass islands on a wide expanse of walkable sand that holds no building and no deposit, so open-field battles from the first minute and no forward bases. Every home island has a pond, fields and a quarry; every neutral island is an oasis ringed with wheat out to its shore that has to be cleared to be taken; an island at the centre carries the orchard. Fair for any colony count by rotation. Played and approved over three iterations.
  • Everglades: a dense wetland, the same everywhere and across the wrap: pools and sloughs on a jittered lattice, grass between them thick with wood and wheat that grow back at the engine's top rate and close the lanes unless kept cut, sand beaches as the lanes that stay open, and every colony in a levee-ringed clearing with a pond and a kit. The generator bends rather than refuses (clearings shrink, the ring widens, routes are opened to any boxed-in colony), so no size and colony count fails.
  • Spider web: an orb web of land spun over open water: spokes from a hub with an orchard out to a frame thread, capture threads sagging between them, and every colony on a pad where its spoke meets the frame. Every ground route follows the threads, so knots are the places to hold and the water between is crossed only once colonies can swim; dew-drop islets carry prizes. The capture threads are a spiral with one arm per colony, so the whole web is fair by rotation. Approved from rendered results.
  • Coral: every colony is a sea fan of land: a trunk from its pad on the rim forks level after level towards the middle, so each colony's coral is a triangle opening inwards and neighbouring fans reach into each other's gaps. Land gets thinner and richer away from home (stone at forks, fruit on tips). The fan is grown once and turned round the map, and every branch keeps a strait from every colony's copy, so it is fair by rotation. Approved from rendered results and early play.

Lattice was added and then removed within this branch, and never shipped.

Resource amounts and switches. Every playable generator now exposes the resources it places as amount controls, and its own sub-behaviours as on/off switches: 56 amount controls and 22 switches across 14 generators.

  • An amount is a percentage of that generator's own default, 0 to 300 in steps of 25, default 100. It scales the numbers that already decided that amount, so at 100 every map is exactly what it was.
  • Switches turn one behaviour on or off while leaving the map playable either way — Fjord's lake-to-fjord connection, Isles' land bridges and sandy beaches, Maze's sand roads and dead-end treasure, River's winding course, stone on hilltops for the height-field generators, and so on.
  • Fairness placements are never scaled — starter kits, the 1:1 guaranteed wheat and wood, Fjord's per-bank guarantees and Ring world's starter patches — so an amount of 0 empties the ambient layer but still leaves every colony a start.
  • High amounts used to wall a colony in with nowhere left to build, so generation now reopens a cramped start, and only at non-default amounts.

Fairer starts and resources.

  • Choosing starts: height-field generators score candidate sites as they will be built.
  • Scoring: every finished map's colonies are scored on fertility, wheat and wood reach, room, resource depth and isolation, and that score picks the best of five rolls.
  • Reachability backstop: guaranteeStartingResources clears resource walls that seal a colony off from wheat or wood, and tops up whatever is still missing. Designed walls can be protected from it.
  • Scatter: ambient resources follow fertility, and algae now actually places, by water body.
  • Fertility overlay: generated maps get a populated fertility overlay.

Lobby.

  • Controls: random maps expose each landscape's controls, with amounts and switches alongside them.
  • Checkboxes: every on/off option is a check button in both the lobby and the map editor, reachable by mouse or keyboard.
  • Randomize under the preview rolls the same settings with a new seed.
  • Reset to defaults restores the landscape's registered values.
  • Default size: random maps now default to 256×256.
  • Catalog order: Contested commons and Maze lead; the four newest generators sit mid-list.
  • Landscape picker: the Landscape field opens a full-window modal (LandscapePickerScreen) that shows every landscape as a freshly generated map at the draft's current size and colony count, with colony markers and a Regenerate all button. Previews roll on background threads (LandscapePreviewer, at most four). The map you pick is the map you play: the lobby rolls the seed behind the tile you chose instead of sampling five, until the next edit. The picker takes only names and requests, so the editor and multiplayer lobby can reuse it.

Fixes found along the way.

  • Queue overrun: computeDistances overran its queue on repeated sources, which made Isles depend on leftover heap memory. Isles moves to revision 2, and its 128×128 seed 5 changes.
  • Algae scatter: algae scatter never placed anything.
  • Rectangular maps: previews and wrapped start coordinates were wrong.
  • Old random: starts had a wood-poor bias.
  • Resource walls: colonies could be walled off from wheat or wood by resources.
  • Lobby settings: a Fjord lake size of 50 reloaded as 30, and a Fjord lake size of 0 or 90, a Ring world lake density of 0, or an Old growth lake size above 100 made the whole settings file fail to load.
  • Contested commons past four colonies: its whole-map colony spread cost map area × colonies² per pass behind a fixed budget of 20 million evaluations, so it failed on every seed past four colonies at 256 and at any colony count at 512. The search now scores each tile from its two nearest weighted distances, kept current as points move: the same minimums in the same scan order, so every seed that generated before gives a byte-identical map (147 configurations checked), while 8 colonies at 256 now rolls in about 0.2 s and 12 at 512 in about 1 s. Revision stays at 2.
  • Concurrent game construction: libusl's static per-type native prototype is repointed at every Game's heap on construction; two games built at once corrupted each other's heaps. That update is now under a mutex.

Translations. Every new label and message is in all 33 text tables — English plus 32 translations. Words a catalog's own language spells as English does (Polder, Coral, Spiral, Plazas, Canals, Grain, Diagonal in the languages concerned) are listed as reviewed shared vocabulary. The Brazilian Portuguese catalog's entries for this branch were rewritten after an earlier pass had used Breton.

Cleanup and safety net

A second pass over the generator tree, every step verified byte-identical against a golden table:

  • Golden maps and colony sweep: MapGeneratorGoldenTest keeps a per-platform table of the map every generator produces for fixed seeds, sizes and colony counts, keyed by revision; a map that changes at an unchanged revision fails CI. --sweep rolls every playable landscape at every colony count the lobby offers and fails any cell where no seed generates. Its first run found Shattered coast unable to seat 12 colonies on most seeds; that placement now relaxes its spacing on a crowded map. macOS arm64 and Linux x86-64 rows are committed, and CI runs the check with --require-rows, so a platform CI builds on cannot pass it without rows; --print runs first so a new platform's rows are in its log.
  • One style: a .clang-format in the house style (tabs, Allman, 100 columns) and the whole tree reformatted, with no map changed. The height map's raw arrays, debug dumps, noise macros and dead diagnostics are retired.
  • A shared terrain layer: shared/Grid holds the torus arithmetic, the eight-connected flood, the walkable mask, units by team and the colony-reachability check that seven generators and their validators each carried a copy of; 530 lines net removed.
  • Structure: the five functions over 200 lines (Contested commons, Rugged archipelago, Isles, Shattered coast, City states) are split into named stages around small layout structs, with direction tables replacing hand-written switch cases where the draw order allowed it.
  • Lobby responsiveness: the preview's five candidate rolls run on the landscape previewer's workers and only the winning seed is rolled on the UI thread, so a 512x512, 12-colony draft no longer freezes the lobby.
  • Measured, not done: caching random-stream lookups by name gained one to two percent and was reverted; the legacy preferences adapter stays as a reader, since old files must still load.

Fairness in real play

tools/map_fairness_tournament.py plays Nicowar free-for-alls on one generated map across many engine seeds and asks whether the same starting positions keep winning, which is a different question from the structural score scoreStarts reports. Per-start win counts get an exact multinomial or chi-square test, Wilson intervals, and Benjamini-Hochberg correction across generators; GLOB2_TEAM_RESULTS reports per-team elimination order, so a game that reaches the tick cap still yields a ranking instead of being discarded. Presets live in tools/map-fairness/, the method is written up in docs/map-generators/FAIRNESS_TOURNAMENT.md, and test/test_map_fairness_tournament.py covers the statistics offline.

What a partial run of 309 games across all 15 generators found:

  • The win rate follows the map's colony index, not the engine's team index. Fit to the same games, a slot-only model holds (p=0.91) while a team-only model is rejected (p=0.005).
  • Colonies 2 and 3 win about twice as often as colonies 0 and 1 - 16.5 / 18.4 / 32.4 / 32.7 % pooled over all 15 generators. It holds among decisive games alone (16.5 / 19.2 / 34.4 / 29.9), so tick-cap adjudication is not what produces it.
  • No individual generator departs significantly from that shared pattern. Measured against the pooled profile rather than a uniform quarter, the largest departures are Fjord continent and Old random, and neither survives correction across 15 generators. Per map, the harness reports no biased map after Benjamini-Hochberg.
  • Earlier reports of an engine team-index bias are withdrawn. A 96-game smoke run put team 1 at 38.5% (6.5 pp, p=0.034), a second 96-game run did not replicate it (p=0.43), and over 309 games team 1 is the weakest index.
  • The start-quality scorer does not predict wins, so scoreStarts is best read as a structural check rather than a fairness guarantee. It is also not rotation-invariant: build_sites counts by top-left anchor.

That run covered 2 of the 4 rotations and about 20 games per generator, so the pooled result is solid but per-generator conclusions are underpowered. The remaining games are not part of this PR.

Two full-rotation runs on this head (tools/map_fairness_tournament.py, Nicowar in every slot, 128×128, 4 colonies, best of 5 lobby rolls per seed, tick cap 90,000; every rotation of team numbers over starts is played, so wins by start carry no team-index effect and wins by team index carry no map effect):

  • baseline (Symmetric arena only, six maps, 96 games, 69 decided by elimination, 2 of 2 re-run games reproduced exactly): position bias 0.0 pp against a fair-map floor of 8.7, no map biased after Benjamini-Hochberg, wins by colony index 26 / 15 / 29 / 26 (p 0.17). Wins by team index 25 / 18 / 18 / 35 (p 0.053), team 3 at 36.5% [27.5%, 46.4%]: the engine's processing-order effect sits at the edge of detectability on a map with nothing else in it, and is the thing to keep measuring.
  • smoke (Symmetric arena, Contested commons, Watershed, Crater lakes; three maps each, 96 games, 82 decided): the same numbers as the 2026-09-12 run in FAIRNESS_TOURNAMENT.md, game for game. Team index pooled over all four generators 28 / 21 / 23 / 24 (p 0.80). Symmetric arena shows no start bias (0.0 pp, floor 14.4). The three asymmetric generators do, per map rather than per colony index: Contested commons 35 pp with 3 of 3 maps biased, Watershed 33 pp with 2 of 3, Crater lakes 29 pp with 2 of 3, and the best start on those maps wins 2.5 to 3.5 times its fair share whichever team plays it. The start scorer only partly predicts which (rho 0.59 on Contested commons, 0.68 on Crater lakes, none on Watershed).

The standard preset (every generator, 2,160 games, about 5.5 hours at 3 jobs) is not part of this PR; its generator list predates the landscapes and it belongs with the per-generator tuning that follows this merge. Reports and CSVs for both runs are under artifacts/map-fairness/ locally and available on request.

Compatibility

  • Saves and replays contain full worlds, so generator changes don't alter existing files.
  • Generator ids: legacy numeric ids are unchanged. The new ids are 9 and 11–21; 10 was used only by Lattice inside this branch and stays retired.
  • Lobby settings: existing custom-game-settings.txt files still load, bridged through the legacy descriptor; the file gains an optional options section for the new controls. Round trip, older files and corrupt-file recovery are tested.
  • Determinism: generation is deterministic for a given seed, revision and platform. Cross-platform identical generation is not a promise of this framework.
  • Fertility rewrite: Fertility::Field also backs the editor's fertility tool and old-save migration; its effect on migrated saves has not been separately checked.
  • Per-thread random stream: the generator behind syncRand() is now thread_local, so the picker's background previews neither disturb nor race the menu's live colony. The simulation runs on one thread and its sequence is unchanged: headless --nox replays from the previous and the new client binary are byte-identical over 1200 ticks (macOS arm64).

Verification

Run on the head before City states (82e01557f), macOS arm64, release build, and repeated for the contract tests and translations on the final head (7b221ff64):

  • Build: full scons build.

  • CI: linux (ubuntu:22.04), linux (ubuntu:24.04) and windows (mingw-w64) all pass. near and far are macros in Windows' windef.h, and two generators had used them as identifiers, which kept the mingw job red; both are renamed, verified as a pure rename.

  • MapGeneratorDefaultsTest: all four groups pass. That covers registry and control contracts, seed repeatability including after an intervening map, RNG isolation, legacy sentinels, algae placement, and lobby/editor control memory.

  • CustomGameSetupHarness: passes headless, and windowed at 640×480 and 1000×700 with all five groups green at each size, including clicking Randomize, Reset to defaults and the new checkbox rows through the production controls.

  • TestsRunner: 198/198.

  • Offline tournament statistics: test/test_map_fairness_tournament.py passes.

  • City states: 999/1000 single rolls over ten configurations (2 to 12 colonies, 128 to 512 maps) pass generation and its own world validation; the one failure is a 12-colony 512 roll whose landing walks spread past the bound, which the lobby's best-of-five absorbs. Rendered rolls, the sweep and fairness measurements are on a page linked from the review thread.

  • Tidal flats: 1000/1000 single rolls over ten configurations (2 to 12 colonies, 128 to 512 maps) pass generation and its own world validation.

  • Translations: data/check_translations.py --strict reports 0 structural errors; test/test_translations.py, test/test_font_coverage.py and test/test_text_area_layout.py pass.

  • Generation sweeps with MapGeneratorStudy:

    • all 15 generators at the 256×256 default, 10 seeds each, with 0 failures;
    • the four newest generators at 50 default seeds each, with 0 failures, plus every control's endpoints, colony counts, and sizes from 64 to 512 including rectangles;
    • every new amount's extremes and every switch's other state generate cleanly.
  • Defaults unchanged: 45 map dumps (15 generators × 3 seeds) are byte-identical across both merges into this branch and across the Windows rename, so no generator revision moves beyond the Isles change noted above.

  • Shared-code changes were checked against before/after map dumps:

    • the protected-wall mask, algae fix, catalog reorder and overrun fix leave all sampled maps byte-identical, except the Isles seed noted above;
    • Symmetric arena maps were independently checked for exact rotational symmetry.
  • Cleanup pass (57e424cde to e2071ba27): golden check 126 rows with 0 failures after every step, sweep 0 failing cells, MapGeneratorDefaultsTest and CustomGameSetupHarness (headless, 640x480, 1000x700, SDL-driven flow, preferences modes) green on the final head; the full-rotation smoke tournament reproduced 2 of 2 re-run games exactly.

  • Landscape picker and Contested commons fix (adf81b19b, 517ea16d9): CustomGameSetupHarness passes headless and windowed at 640×480 and 1000×700 (three runs each), with new coverage of the picker's previews on background threads, selection by click and keys, regeneration, and the lobby rolling the very seed the picker showed; a ThreadSanitizer build of the visual harness reports only a pre-existing VoiceRecorder teardown race; MapGeneratorDefaultsTest gains a regression rolling Contested commons at 256/8, 512/4 and 512/12 that fails on the previous code and passes now.

Screenshots and example maps for every generator are in this comment. Seed studies were generated locally under ignored artifacts/ and are available on request.

  • Spider web and its primitives (745098856): new shared Drawing (strokes, Bezier threads, turned shape fills), KitFrame, plantFields, scatterClumps and secureStartingCrops, with Tidal flats, City states, Everglades and Ring world moved onto them byte-identically (133 existing golden rows unchanged); golden 140 rows with 0 failures, --sweep 0 failing combinations, a stress run of 1,662 / 1,742 rolls over 64–512 maps, 1–12 colonies and every control extreme (the misses are the up-front refusal of 6+ colonies on 64×64), median start fairness 0.965; MapGeneratorDefaultsTest with new toolkit checks and check_translations.py --strict pass.

  • Coral, rectangular maps and Spider web tuning (e95f98865): new shared growBranches, pathClearance, tracePath, Stretch, sprinkleSand and algaeGrowthChance; golden 168 rows (a 512×256 row per generator added) with 0 failures and every other generator's square maps byte-identical; --sweep 0 failing combinations; stress runs of 1,642 / 1,862 Coral rolls (median start fairness 0.966, every miss an up-front refusal) and 1,842 / 1,922 Spider web rolls (median 0.960) over 64–512 maps, 1–12 colonies and every control extreme; rectangle stress median fairness 0.96 for both; MapGeneratorDefaultsTest, check_translations.py --strict and clang-format pass.

  • Review response (ab95800c2, 2026-09-14): the mingw near clash, the test/ link surface, the Stone Highlands pass sorting and GCC -Warray-bounds noise, Glob2.cpp style, 47 untranslated keys and the Old growth preferences bound, with master (Clamp status-bar fills instead of aborting on overflow #194, Rename the CORN resource identifier to WHEAT #269) merged in and the CORN → WHEAT rename applied branch-wide. Locally on macOS arm64: full build, MapGeneratorDefaultsTest, golden 248 rows with 0 failures (with and without --require-rows), --sweep 0 failing combinations over 32 generators, CustomGameSetupHarness in all three modes, MenuColonyHarness, test/TestsRunner and every test/ harness, and all four translation checks. CI: run 34844458453 green on all three jobs, including the savegame harness on Windows, the test/ suite on Linux and the golden check against committed rows.

Not run: cross-platform per-tick checksum comparison; maintainer playtesting of each generator; native-speaker review of the new translations, which are machine-assisted.

Play-note batch (2026-09-14)

Seventeen tweaks from playtesting the branch, each explained in the generator's header comment and in the docs:

  • Tidal flats: six oases and eight sandbars per wedge by default (were three), smaller, rounder and with tighter gaps so crowded wedges seat more; the smallest oasis keeps its pond inside a ring of wheat. A 128 map with six colonies is the limit and fits only an oasis or two.
  • Carousel: the sea inside the ring stays a lagoon with algae, the inward farm and the home kit are gone; lanes and courts on the lagoon keep a 5-tile stone band (a diagonal spoke on the old 3-tile margin lost every grass tile to its beach and seal, which cut colonies off from the plaza).
  • City states: an archipelago of round islets in the sea outside the design circle, where the torus wraps (a square's corners, a rectangle's bands): one on the wrap point and rings of eight, sixteen and so on round it with the map's own four-fold symmetry, each islet radius 11 with a 10x4 building plot and a small prize, three tiles of water from everything; the Barrens home layout is gone.
  • Standard farms: sand bridges run clean across the whole farm, crop rows and water alike (Old town, Carousel, Switchbacks).
  • Amphitheatre: starting towers stand against the arena's outer wall by the colony's own ramp, covering the arena.
  • Starting tower level defaults to 1 on Carousel, Amphitheatre, Switchbacks and Canals.
  • Switchbacks, Carousel, Polder: no wheat and wood kit in the homes; Polder's wood share 8% (was 15%).
  • Rain shadow: streams 7 deep, pass roads 18 tiles into each valley and joined to the streams' beaches.
  • Canals: a block-shape choice of squares or hexagons.
  • Maze and Stone highlands amounts are percentages like every other landscape; the resource-amount audit is in the framework doc.
  • Catalog order shuffled once (no bias towards the landscapes written first); the lobby and editor open on the first entry, Fingerprint.
  • Lobby: random maps are the default (the premade library preselects FourSquares1 when first opened); Reset to defaults moved to the top of the map column with Random parameters beside it, which redraws any set the generator or the world refuses (up to six times) until a map exists; the landscape picker gains Randomize parameters and Reset to defaults and hands the lobby the shown map's parameters as well as its seed; a generated map's fairness and score show under its preview with an (i) opening the per-colony breakdown. Reloading every saved setting, generator options included, is covered by the preferences harness modes.

Verification on macOS arm64 (release): full build; MapGeneratorDefaultsTest; golden 248 rows with 0 failures under --require-rows, with linux-x86_64 rows regenerated on a Linux x86-64 box (gcc); --sweep 0 failing combinations over 30 playable landscapes at 128 to 512 and 2 to 12 colonies; CustomGameSetupHarness headless with captures, preferences-write and preferences-read; MenuColonyHarness check; all four translation checks. Screenshots of the compact lobby with the new buttons and the fairness line, the breakdown screen, the randomized picker and example maps are on a results page linked from the review thread, and every one reproduces with build/src/CustomGameSetupHarness artifacts/custom-game/compact (captures) and tools/render_map.py one <landscape> --size 256 --set teams=N.

Feel changes to playtest

  • 256×256 default: random maps start bigger, so games take longer and colonies spread out further.
  • Choosing a landscape: the dropdown is gone; the Landscape field opens the picker, and picking a tile plays that exact map. Previews take a few seconds to fill in on a large map with many colonies.
  • Amounts and switches: defaults reproduce today's maps exactly, but they open up deliberately poorer or richer maps, and switches like Isles' land bridges or Fjord's lake connection change a map's shape as much as its economy.
  • Watershed: the economy follows the rivers, and before swimming pools first contact happens at the fords.
  • Stone highlands: passes are the only ground routes all game, stone is effectively unlimited, and first contact is slow.
  • Symmetric arena: the early game is calm, and the orchard in the centre is the map's only fruit.
  • Ring world: every colony fights on two fronts, and 5 colonies on 128×128 is crowded.
  • Spider web: ground routes are narrow threads, so knots and the hub decide early fights; swimming changes the map entirely. A sand road (on by default, like Maze's) runs down every spoke and whole thread, and threads now carry about twice the wheat and wood.
  • Coral: the front between colonies is a zigzag of fingertips, a short swim apart; bases start on a pad at the rim with one trunk to hold, and supply lines thin at every fork. Sand roads run down every forking branch.
  • Rectangular maps: Fjord continent, Tidal flats, Spider web and Coral now stretch to fill a rectangle as an oval instead of sitting in a disc on the short side; City states stays round, because stretching it made its homes unequal.
  • Maze and Fjord continent: both changed substantially through playtesting on this branch.
  • Play-note batch (2026-09-14): Carousel's middle is a lagoon, so its farms are all outside the ring and the plaza is reached only down the spokes; City states' outer sea is an archipelago of forward posts for whoever swims first; Tidal flats has far more oases and sandbars; starting towers everywhere are level 1, so players upgrade their own; three landscapes' homes start without a wheat and wood kit and rely on their fields and farms; the lobby opens on a random map.

Known limits

  • Symmetric arena accepts only 2, 4 or 8 colonies. scoreStarts counts build sites by top-left anchor, so its fairness score reads just under 1.
  • Coral with 12 colonies joins its trunk tips into a small ring of bridges around the centre.
  • Watershed has one river network, so a 512×512 map keeps a desert strip far from the delta.
  • Untested AI behaviour: how the AIs cope with Highlands passes and Watershed fords.
  • Some starts win more often than others, and this PR does not fix it. A partial run suggested colonies 2 and 3 win twice as often as 0 and 1; a full-rotation run (96 games) shows no colony-index skew and no engine team-index bias, and start metrics by colony index are flat over 680 maps (tools/colony_start_metrics.py). What is real is per map: on seven of nine asymmetric maps one start won seven or eight of eight games whichever team played it, and the start scorer only partly predicts which. The fairness reference records the numbers; teaching the scorer what a dominant start has is the open work.
  • Amount and switch combinations are covered by generation sweeps, not by playtesting. Extreme amounts are deliberately unbalanced.

Features and gameplay changes need a human maintainer's review and playtest: @Giszmo, @kylelutze.

🤖 Generated with Claude Code

https://claude.ai/code/session_014TBmePvnWuhjRHwGdT5UPq
https://claude.ai/code/session_01GpUW6Ny43aUA1EzThn6cYE

@genixpro
genixpro force-pushed the codex/map-generator-defaults branch from 9e9f0f1 to b74f6df Compare September 10, 2026 21:36
genixpro added a commit that referenced this pull request Sep 10, 2026
#238 (already merged) added CustomGamePreferences.h, which serializes
CustomGameSetup::generator by taking Sint32 MapGenerationDescriptor::*
member pointers directly. #240 replaced generator's type with
GenerationRequest, whose method-specific options live in a generic
std::map<std::string,int> instead of fixed struct fields — an
architecture change, not a rename — so the file no longer compiled.

- CustomGamePreferences::encode()/decode() now convert through
  toLegacyDescriptor()/fromLegacyDescriptor() (the compatibility
  adapter #240 already built for exactly this kind of interop), so the
  on-disk wire format and its corruption-recovery bounds are unchanged.
  decode()'s method-validity check now asks the live GeneratorRegistry
  instead of a hardcoded 1-8 range, so it stays correct as generators
  are added or retired.
- Widened several fields() bounds (terrain weights 0-64 -> 0-100,
  riverDiameter's max 64 -> 65, oldIslandSize 1-64 -> 1-70) to match
  the modular registry's current ranges. These are approximate,
  same as before: the reused legacy fields (e.g. riverDiameter also
  stands in for lake size/channel width/bridge width) don't have one
  true bound, so this is a safe envelope, not a tight per-method one.
  Without this, decode() could reject a preferences file that a normal
  save legitimately produced (oldIslandSize's own default already
  exceeded the old 1-64 bound for any method other than Isles/Old
  Islands).
- Found and fixed a related crash bug in the compatibility adapter
  itself while tracing this: Lattice and Maze register wheat/wood/
  stone/algae controls with no entry in legacyField()'s mapping table,
  so converting either method through toLegacyDescriptor/
  fromLegacyDescriptor threw an uncaught std::invalid_argument. Added
  the four missing mappings (they match pre-existing legacy struct
  fields exactly) and changed every other option with no legacy slot
  (loopiness, home-radius, cell-size, ...) from throwing to falling
  back to its control's default value, so a newer generator's full
  option set can never crash this adapter again.
- test/CustomGameSetupHarness.cpp: preferencesModel()/preferencesScreen()
  built a GenerationRequest via the same member-pointer approach;
  updated both to build a temporary MapGenerationDescriptor and convert.
  Switched preferencesModel()'s method from Old Islands to Crater Lakes
  (one of the four modern height-map generators that still exposes a
  repeat-landscape control; Old Islands never did in the new registry,
  so it always round-tripped back to 0). The per-field assertions in
  preferencesScreen() now compare against the same achievable
  conversion rather than raw field maximums, since only the options a
  method actually registers survive a GenerationRequest round trip.

Verified: full scons -j8 release=1 server=0 client build is clean.
CustomGameSetupHarness passes in default, preferences-write and
preferences-read modes. MapGeneratorDefaultsTest and
MapGeneratorStudy --catalog also pass, unaffected by the adapter fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KFxsZmLM4qsovemHqDrHGP
@genixpro
genixpro force-pushed the codex/modular-map-generators branch from e0508e0 to 2a0895c Compare September 10, 2026 22:14
@genixpro

Copy link
Copy Markdown
Contributor Author

Rebased onto the (also just-rebased) #238 branch — it forked from the same codex/custom-game-ux point as #238, so I used the same rebase --onto approach to replay only this branch's own 6 commits.

Most conflicts were pure append/append (translation files) or a stale-vs-final doc mismatch, resolved by taking this branch's own final version of docs/map-generators/README.md directly rather than trusting the intermediate per-commit auto-merge (one commit in this branch's own history trims that doc down over several later commits; replaying it against my manual resolution of an earlier commit briefly duplicated content — using the actual final committed version sidesteps that entirely). test/CustomGameSetupHarness.cpp had one large genuine conflict (model()/engine()/sessionReplay()/reload()/main()); after stripping whitespace, master's and this branch's versions turned out to be functionally identical except for the map-generator loop itself, which I merged by keeping master's already-independently-added content (two new test methods from #237's tail commits, extra AINames assertions) and splicing in this branch's GeneratorRegistry-based loop in place of the old hardcoded 8-method one.

The real find, after conflicts resolved: the branch didn't compile. #238 (merged in the meantime) added CustomGamePreferences.h, which serializes CustomGameSetup::generator via direct Sint32 MapGenerationDescriptor::* member pointers. This branch replaced generator's type with GenerationRequest, whose per-method options live in a generic std::map<std::string,int> — an architecture change, not a rename, so #238's file had no path forward. Fixed by routing encode()/decode() through toLegacyDescriptor()/fromLegacyDescriptor() (the compatibility adapter this PR already built for exactly this), keeping the on-disk format and corruption-recovery bounds unchanged, and widening a few of those bounds to match the registry's current ranges (they'd gone stale — e.g. oldIslandSize's own default already exceeded the field's declared max for any method other than Isles/Old Islands, so a completely ordinary preferences save could fail to load back).

While tracing that, I also found and fixed an unrelated crash in the compatibility adapter itself: Lattice and Maze register wheat/wood/stone/algae controls that legacyField()'s mapping table doesn't know about, so converting either method through the adapter threw an uncaught std::invalid_argument. Added the four mappings (they match pre-existing legacy struct fields exactly) and changed the fallback for every other option with no legacy slot (loopiness, home-radius, cell-size, ...) from throwing to using the control's default value, so a newer generator's full option set can't take the adapter down.

Verification on this head:

  • Full scons -j8 release=1 server=0 client build is clean.
  • CustomGameSetupHarness passes in default, preferences-write and preferences-read modes.
  • MapGeneratorDefaultsTest and MapGeneratorStudy --catalog pass, unaffected by the adapter fix.

Requesting review from @Giszmo and @kylelutze (same as #238, since this stacks directly on it). No merge performed.

genixpro added a commit that referenced this pull request Sep 10, 2026
Merging directly. CI green on all three platforms, no merge conflicts, no reviewer objections. Stacked map-generator work (#240) follows.
Base automatically changed from codex/map-generator-defaults to master September 10, 2026 23:23
genixpro and others added 8 commits September 10, 2026 19:25
#238 (already merged) added CustomGamePreferences.h, which serializes
CustomGameSetup::generator by taking Sint32 MapGenerationDescriptor::*
member pointers directly. #240 replaced generator's type with
GenerationRequest, whose method-specific options live in a generic
std::map<std::string,int> instead of fixed struct fields — an
architecture change, not a rename — so the file no longer compiled.

- CustomGamePreferences::encode()/decode() now convert through
  toLegacyDescriptor()/fromLegacyDescriptor() (the compatibility
  adapter #240 already built for exactly this kind of interop), so the
  on-disk wire format and its corruption-recovery bounds are unchanged.
  decode()'s method-validity check now asks the live GeneratorRegistry
  instead of a hardcoded 1-8 range, so it stays correct as generators
  are added or retired.
- Widened several fields() bounds (terrain weights 0-64 -> 0-100,
  riverDiameter's max 64 -> 65, oldIslandSize 1-64 -> 1-70) to match
  the modular registry's current ranges. These are approximate,
  same as before: the reused legacy fields (e.g. riverDiameter also
  stands in for lake size/channel width/bridge width) don't have one
  true bound, so this is a safe envelope, not a tight per-method one.
  Without this, decode() could reject a preferences file that a normal
  save legitimately produced (oldIslandSize's own default already
  exceeded the old 1-64 bound for any method other than Isles/Old
  Islands).
- Found and fixed a related crash bug in the compatibility adapter
  itself while tracing this: Lattice and Maze register wheat/wood/
  stone/algae controls with no entry in legacyField()'s mapping table,
  so converting either method through toLegacyDescriptor/
  fromLegacyDescriptor threw an uncaught std::invalid_argument. Added
  the four missing mappings (they match pre-existing legacy struct
  fields exactly) and changed every other option with no legacy slot
  (loopiness, home-radius, cell-size, ...) from throwing to falling
  back to its control's default value, so a newer generator's full
  option set can never crash this adapter again.
- test/CustomGameSetupHarness.cpp: preferencesModel()/preferencesScreen()
  built a GenerationRequest via the same member-pointer approach;
  updated both to build a temporary MapGenerationDescriptor and convert.
  Switched preferencesModel()'s method from Old Islands to Crater Lakes
  (one of the four modern height-map generators that still exposes a
  repeat-landscape control; Old Islands never did in the new registry,
  so it always round-tripped back to 0). The per-field assertions in
  preferencesScreen() now compare against the same achievable
  conversion rather than raw field maximums, since only the options a
  method actually registers survive a GenerationRequest round trip.

Verified: full scons -j8 release=1 server=0 client build is clean.
CustomGameSetupHarness passes in default, preferences-write and
preferences-read modes. MapGeneratorDefaultsTest and
MapGeneratorStudy --catalog also pass, unaffected by the adapter fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KFxsZmLM4qsovemHqDrHGP
Rebased onto master now that #238 has merged. That surfaced a real compile
break: the harness still called Map::oldMakeIslandsMap/Game::oldMakeIslandsMap,
which the registry-driven generator rewrite removed.

Route it through MapGenerator::generateMap(Game&, const MapGenerationDescriptor&,
seed) instead, which owns map sizing and the game association internally. Seed
explicitly since generation determinism no longer follows the global sync-rand
state.

Also start from setMethodDefaults() rather than hand-picked constants: #238's
defaults tuning tightened several control ranges (island-size moved to 50-70,
the shared "workers" control caps at 8), so the harness's old literals
(oldIslandSize=35, nbWorkers=48) now fail request validation. Defaulting first
and overriding only what this decorative colony actually needs to differ keeps
it from rotting the same way again as ranges keep moving.

Verified: full client and menu-colony-harness build clean; `check`, `navigation`,
and `generate` subcommands all pass (generate grows the colony from 8 to 56
units over the same 12,000-tick warmup, confirming the reduced starting worker
count doesn't defeat the decorative intent).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Le23P4h9QuDjwExuK6HU96
@genixpro
genixpro force-pushed the codex/modular-map-generators branch from 2a0895c to db88fb0 Compare September 10, 2026 23:35
@genixpro

Copy link
Copy Markdown
Contributor Author

Retargeted onto master and rebased now that #238 has merged. Master's tree was byte-identical to #238's old branch tip (clean squash merge), so git rebase --onto origin/master origin/codex/map-generator-defaults replayed this branch's own 7 commits with zero conflicts — no manual reconciliation needed this time.

That surfaced a real compile break in CI, though: tools/MenuColonyHarness.cpp still called Map::oldMakeIslandsMap/Game::oldMakeIslandsMap, which the registry-driven rewrite removed. Fixed by routing it through MapGenerator::generateMap(game, d, seed). Two follow-on issues surfaced once it actually ran end-to-end (CI only exercises check/navigation, not generate, so these were latent):

  • The shared workers control is now capped at 1–8; the harness's old nbWorkers=48 fails validation. Clamped to 8 — verified the 12,000-tick AI warmup still grows the decorative colony to 56 units/11 buildings by the end, so this doesn't defeat the intent.
  • island-size on Rugged Archipelago (the renamed Old Islands) is now 50–70; the old oldIslandSize=35 fails too. Switched to d.setMethodDefaults(...) first and only overriding what the tool actually needs to differ, so it stops rotting every time a generator's tuned ranges move.

Verified on this head (db88fb0c9):

  • Full scons -j8 release=1 server=0 client build is clean.
  • scons -j8 release=1 server=0 menu-colony-harness builds clean.
  • check, navigation, and generate subcommands all pass.

CI is running now. Requesting review from @Giszmo and @kylelutze (unchanged from the original description). No merge performed.

claude and others added 12 commits September 10, 2026 22:15
…or bias

Building on #240's modular generator rewrite: swamp, river, islands, crater
lakes, old-random and old-islands all pick each team's resources through a
step that has no idea where any *other* team ended up, or in old-random/
old-islands' case scores compass directions independently per team without
comparing outcomes across teams. A colony's proximity to wheat and wood was
left almost entirely to how the (still resource-blind) starting-position
search happened to place it, so one team could land next to both while
another got only one, or occasionally neither within playable range.

Add shared/Resources::guaranteeStartingResources, which floods outward from
each team's boot tile through the same walkable-space search used to judge
distance (isHardSpaceForGroundUnit), so anything it places is reachable by
construction rather than merely straight-line close. It tops up only teams
that are missing wheat or wood within the range map_generator_study.py
already scores as viable; already-served teams are untouched. Wired into
the four generators that share Terrain::generateHeightField, and into
old-random/old-islands after their own start placement.

Also fix a starvation bug in old-random's resource search: its 8-direction
scan hardcoded a 4th slot to CORN, so every colony got a guaranteed second
wheat deposit while wood only ever got one. The 4th slot is now awarded to
whichever of the two came out narrower for that colony.

Extend test/MapGeneratorStudy.cpp's tuning output with best_wheat_distance/
best_wood_distance (previously only the worst-served team's distance was
tracked) so tools/map_generator_study.py can size the gap between a map's
best- and worst-served colony, not just its worst case in isolation.

Validated with 200 fixed seeds per generator via map_generator_study.py
(artifacts not committed, matching this directory's own convention):
generation success rate is unchanged for every generator (including
old-random and old-islands' existing ~4-5% baseline failure rates); the
wheat-access gap between a map's best- and worst-served colony drops for
swamp, river, islands and crater lakes, and old-random's wood gap and
worst case both drop by roughly two-fifths. Old-random's wheat gap grew in
the same run and a handful of colonies on constrained terrain (river was
the clearest case) can still end up with no reachable wheat or wood at
all; both are documented as open follow-ups in
docs/map-generators/FRAMEWORK_UPGRADES.md; a farthest-point starting-
position search was tried against the latter and measured worse on river
and islands, so it was not kept.

This is a balance-affecting change to established generators and needs a
maintainer's sign-off per this repo's review rules, not just this PR's own
validation numbers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rc6FTwBEB6LKNUmamX9wWW
Follow-up to 777b748. That commit's guaranteeStartingResources fixed most
of the fairness gap but left river with a residual ~5% of colonies
reaching no wheat or wood at all, and left old-random's imbalance direction
flipped rather than closed. Investigated the river failures directly (map
dumps + reachability analysis, not just aggregate metrics this time): a
colony's boot tile was landing on a stretch of perfectly good, well
connected land — confirmed by re-flooding the same tile through terrain
alone, ignoring resources, and finding a landmass orders of magnitude
larger than what the resource-respecting flood could reach. A ground unit
can't walk onto a tile carrying a resource (Map::isHardSpaceForGroundUnit
excludes them), and the noise-band resource painting has no idea what it
might wall in, so it was sealing colonies into small pockets on their own
otherwise-fine landmass.

guaranteeStartingResources now detects this directly: when a team's
resource-respecting reachable area is suspiciously small, it re-floods the
same tile blocked only by water (ignoring resources) and diffs the two
results to find exactly the resource tiles forming the wall's face —
clearing only those, not a surrounding neighborhood, and iterating a
bounded number of times in case a wall is thicker than one tile. A colony
on a genuinely small spot (a real islet on a water-heavy map) is left
alone: there's no larger area on the other side of nothing, so the
terrain-only flood finds nothing bigger and the check is a no-op by
construction.

First attempt at this used a blunt fixed-radius clear around the boot tile
whenever the pocket looked too small, gated only on the team actually
being under-served. It worked for river but silently regressed swamp
(whose terrain legitimately includes small real islands): demolishing a
25x25 tile neighborhood around a "small but actually fine" pocket destroys
perfectly good nearby deposits for no gain when there is no larger
landmass to reach. The precise wall-tracing approach here has no such
failure mode, validated below.

Reconfirmed with the same 200-fixed-seed methodology as 777b748: river's
rate of a colony reaching no wheat or wood at all drops from ~5% to ~3%
(the earlier blunt version reached 0% on this seed range but at swamp's
expense — see above), with the remaining cases past this guarantee's own
search radius. Swamp's numbers now improve slightly rather than regressing
(the false-blame that motivated the blunt version's guard is gone: the
gate is precise instead of merely permission-checked). Crater lakes,
islands, old-random and old-islands are unaffected (the mechanism only
fires where floodReach and a terrain-only flood actually disagree).
Generation success rate is unchanged everywhere. Confirmed the existing
map-generator-defaults-test and a full non-server engine build still pass.

Old-random's wheat/wood imbalance (flipped, not fixed, by 777b748) and the
remaining few percent of unreachable river colonies stay open follow-ups,
called out in docs/map-generators/FRAMEWORK_UPGRADES.md along with why a
join symmetric compass-slot assignment was tried and not kept for the
former.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rc6FTwBEB6LKNUmamX9wWW
The four height-field generators picked colony sites before any resource
was on the map, so a site could only be judged by the shape of the grass
under it: largest contiguous grass rectangle to the first colony, and
everyone after it taking what was left. Every fairness fix so far has been
downstream repair of that decision. This changes the decision.

The ordering turned out not to be a constraint at all. Nothing between the
placement pass and the resource pass reads a boot position or draws from a
random stream, so the two simply swap, and placement can then see what it
is actually choosing between.

Measured the headroom before building anything, via a new `headroom` mode
in the study tool: score every legal site against a single multi-source
flood per resource (one flood answers the question for the whole map, so
scoring a site is a lookup rather than its own search), then find the
narrowest score window still holding enough mutually distant sites. Result:
every map sampled, on every generator, already contained a placement where
all colonies were equally served. The maps were never unfair. The placement
was throwing away fairness the map already had.

shared/StartingPositions::chooseBalancedStarts now picks sites after the
resource pass. A site scores as its *worse* primary resource, since a
colony beside wood but a long walk from wheat is not a good start; sites
are sorted and the narrowest qualifying window wins, scanned from the
low-score end so ties settle in favour of a set that is not merely equal
but good. The legacy search remains as a fallback for maps where no set of
sites can reach both resources.

Across 200 fixed seeds per generator, versus the pre-existing behaviour:
  gap between best- and worst-served colony  9.5-19.0  ->  3.5-4.0 tiles
  worst-served colony's walk to a resource  12.0-18.5  ->  3.5-4.2 tiles
  colonies reaching no wood or wheat at all  up to 5.6% ->  0%
  colonies clearing the study tool's viability bar        all of them
The worst-served colony is now better off than the *luckiest* colony was
before. Verified at 2, 6, 8 and 12 colonies as well (gap 2.4-7.4, widening
with colony count as expected, no generation failures introduced at any
count). Success rates, resource totals and colony separation (30-72 tiles
apart on a 128x128 map) are unchanged. map-generator-defaults-test and a
full non-server engine build pass.

Old-random and old-islands deliberately keep their own placement: they put
resources relative to each boot tile, so moving a colony does not bring its
resources along. The same headroom measurement puts their achievable gap at
8.0 and 0.9 tiles, and old-islands already sits at 0.9.

This changes how these four generators play, and that part is a
maintainer's call rather than a metric's: colonies now start snug against
their wood and wheat instead of in open ground, and buildable room within
reach of the worst-served colony drops 14-28% (still 405-642 free building
sites against a viability bar of 16). Generator revisions are bumped, so a
given seed no longer produces the map it did before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rc6FTwBEB6LKNUmamX9wWW
Placement picks a site for its walk to wood and wheat, but a colony
rewrites its own surroundings the instant it is built: placeStarts()
clears a five by seven box of resources to make room, the swarm itself
becomes four by four tiles of obstacle, and the workers spawn on the row
above the boot tile rather than on it. Scoring against the bare map
therefore credited a site with deposits it was about to destroy and paths
it was about to block.

That was measurable, not theoretical. Instrumenting the search on one seed
showed it choosing four sites scored wood 0, wheat 0 apiece -- exactly
equal -- which the finished map then spread across three tiles, because
how much each site loses to its own construction depends on the shape of
the resources around it.

chooseBalancedStarts now shortlists on the cheap bare-map distance, which
can only understate the built cost and so is a sound filter, then re-scores
the shortlist by simulating the finished colony: clearing box excluded,
swarm footprint impassable, flood starting from the worker row. The offsets
mirror placeStarts() rather than approximating it.

Against the previous commit, over 200 fixed seeds per generator:
  gap between best- and worst-served colony  3.5-4.0  ->  0.92-1.02 tiles
  worst-served colony's walk                 3.5-4.2  ->  2.0-2.3 tiles
Better on both counts at once, and level with old-islands, the
fair-by-construction benchmark. Holds at 2, 8 and 12 colonies (gap
0.35-4.70, widening with count as expected, no failures at any count).
Buildable room, resource totals and colony separation are unchanged.
map-generator-defaults-test and a full non-server engine build pass.

A first attempt instead excluded every site whose deposits sat inside the
clearing box. It tightened the gap to 2.8-3.1 but pushed every colony
roughly twice as far from its resources (worst walk 3.5 -> 6.6 tiles),
trading what matters for what was being measured. Not kept.

Generation costs 63-110ms against 11-14ms, nearly all of it the shortlist
re-scoring, and the first working version cost 1400ms until the visited set
stopped being a linear scan. Imperceptible for a lobby generating one map.
Halving the shortlist halves the cost but loses a third of the gain on
river, so the shortlist stays at 900.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rc6FTwBEB6LKNUmamX9wWW
Map::growResources decides whether a wheat or wood tile expands with

    dwax = (syncRand()&0xF) - (syncRand()&0xF);   // likewise dway
    expand = isWater(x+dwax, y+dway) && !isSand(x-dwax, y-dway);

The difference of two uniform draws from {0..15} has PMF (16-|d|)/256, so
the chance a tile expands is a triangular kernel summed over every water
tile whose mirror through the tile is not sand. FertilityCalculator fit a
curve to the first half of that and dropped the second: its weights were
int(4.2*sqrt((15-|dx|)*(15-|dy|))) and it never looked at sand at all,
overstating exactly the tiles where wheat will not in fact come back.

Fertility::Field evaluates the real thing in closed form. A length-16
forward box followed by a length-16 backward box is exactly the triangular
kernel, so the water term is four linear passes rather than 961 taps per
grass tile, and the sand term is one stamp per sand tile. Measured over 20
seeds on each of eight generators it runs about ten times faster than the
kernel it replaces (the sand correction, not the convolution, is what it
spends its time on) and correlates 0.89-0.997 with it; shattered-coast,
the sandiest of them, is both the least correlated and the slowest, which
is the difference being paid for.

The field takes plain masks and no Map, so map generation can score a
candidate's growth potential without a live game. FertilityCalculator
keeps its API, its deposit-reachability gate and fertilityMaximum, and
clamps to the Uint16 that Tile::fertility holds; the scale is now 65536,
which the overlay normalises away and MapIO stores per tile as before.

The implementation comes from ExactFertilityCache in AIMaximaFarming on
the Maxima branch, extracted here so both callers share one field.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rc6FTwBEB6LKNUmamX9wWW
Placement equalises walking distance to wood and wheat, and on four of
these generators that is now down to two tiles for the worst-served
colony. It is also nearly all the metric has left to give: sampling twenty
maps and keeping the fairest shaves two tenths of a tile off a gap of one.

Distance was always a thin account of a starting position. A deposit at
the door that runs dry, a colony with no room to build, one boxed between
two rivals, and one on ground where wheat never grows back all look alike
to it. StartQuality measures six things per colony on the finished map --
where the swarm is built, its clearing cleared and the workers standing
where they will actually start walking -- and folds them into one number
per map: the weakest colony's quality, gated by how evenly the map shared
quality out, worst * (worst/best)^k.

Fertility carries the most weight of the six because it is the one that
decides whether a colony's wheat comes back at all; the others describe
what it starts with, fertility describes what it keeps. Every factor is
normalised against a fixed reference rather than the map's own best
colony, because the point is to rank candidate maps against each other and
a per-map normalisation scores every map alike. The two distances use the
viability bars the study tool already scores against; the other four have
no such bar, so they sit near the ninetieth percentile of what the twelve
generators actually produce, measured over 2400 maps.

Scoring costs 2.5-5.4ms against 11-61ms to generate, consumes no random
stream, and never rejects a map -- it ranks. Map hashes are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rc6FTwBEB6LKNUmamX9wWW
The lobby already generated up to five maps and kept the first that
succeeded, because some rolls cannot fit every starting colony. Among the
rolls that do fit, some still hand one colony far better ground than
another, and now that there is a score for that, the same loop can keep
the best rather than the first.

Five is a budget, not a knee. Keeping the best of K is the maximum of K
draws from a distribution, so it grows like sqrt(2 ln K): it improves
forever and flattens gradually, and bootstrapped over 200 scored maps per
generator all twelve trace that same curve to within a few percent -- three
candidates capture 44% of what fifty give, five 59%, eight 70%, twenty 87%.
What picks the cutoff is that the lobby generates on the UI thread behind a
500ms debounce: at 61ms a candidate on crater-lakes, five rolls cost 306ms
where eight would cost 489ms and spend the whole budget.

The editor owns its Game and cannot hold a spare, so it takes the winning
seed from GenerationService::bestSeed and regenerates it. That is sound
because generation is deterministic and the score is a pure function of the
finished map; map-generator-defaults-test asserts both, along with the
chosen seed being a candidate no other candidate outscores.

Optimising a composite could have pulled colonies away from their resources
to chase fertility or elbow room. Measured, it does not: the worst-served
colony's walk holds at about two tiles on the four height-field generators
and improves by four on shattered-coast, while fairness rises from
0.81-0.94 to 0.92-0.97 everywhere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rc6FTwBEB6LKNUmamX9wWW
…d static

Profiling map generation under load (users noticed the "keep the best of five
rolls" work from the previous commit made map generation feel much slower)
found chooseBalancedStarts itself, plus Map::isHardSpaceForGroundUnit called
from inside it, accounting for 74-84% of total generation time on swamp,
river, islands and crater lakes: two whole-map resource floods each call it
once per tile, and then every one of up to 900 shortlisted candidate sites
re-simulates its own local flood calling it again, up to ~4225 tile visits
each in the worst case.

That work was entirely redundant. Every call site in this file uses the same
two constants (canSwim=false, team mask=0), which makes checkTile's forbidden-
area test a no-op, and chooseBalancedStarts runs before placeStarts() ever
places a building, so getBuilding() is NOGBID everywhere it looks. Under
those invariants isHardSpaceForGroundUnit(x, y, false, 0) reduces to
!isResource(x, y) && !isWater(x, y) — a pure function of terrain/resource
state that cannot change across the whole search.

buildHardSpaceGrid() computes that once into a flat std::vector<uint8_t>
before the two distanceToResource() floods; all three flood loops (the two
global ones and scoreAsBuilt's per-site one) read the cached byte instead of
calling through checkTile's several field accessors. distanceToResource's
distance vectors and the per-site visited/visitStamp array are narrowed from
int to 16 bits alongside this — every distance this engine can produce fits
comfortably, and the arrays these floods touch millions of times are half
the size to move through cache.

This changes nothing about which sites get chosen or how they get scored,
only how the unchanged answer is computed: MapGeneratorStudy's per-tile
terrain/resource hash is bit-identical before and after on matching seeds
across every affected generator. Measured 28-31% faster end to end on swamp,
river, islands and crater lakes (interleaved before/after binaries, 60
generations per generator, to separate the effect from this machine's own
run-to-run noise) with zero measurable change on the nine generators that
don't call chooseBalancedStarts, which is the expected result and also rules
out the noise itself producing the improvement.

Verified: full scons client build and scons -C test suite (198 cppunit
cases) pass. MapGeneratorStudy succeeds across all 13 generators at 5 seeds
each, both before and after. FertilityFieldTest and MapGeneratorDefaultsTest
are unaffected (this file's callers are unrelated to either).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CkotX2daY8YTuFcRxFf5ba
Investigated as part of the same profiling pass as the previous commit:
Fertility::Field::rebuild is the dominant cost on Lattice and Maze (57% and
39% of total generation time), but unlike chooseBalancedStarts this is not
redundant recomputation — Fertility::forMap has exactly one caller
(scoreStarts) and runs once per roll, confirmed via its full call chain.

Both generators are simply water/sand-heavy by design (thick maze corridors,
real shorelines): instrumentation shows ~6,100 water and ~1,260 sand tiles
for Lattice and ~4,100/~1,350 for Maze against a 16,384-tile map, and the
sand-correction path costs one 31x31 weighted stamp per sand tile no matter
how the adaptive rule picks between it and the water-splat alternative.

first/second hold the box-blur passes' single-axis partial sums. The first
pass (a plain 16-wide box over 0/1 water values) tops out at 16; combined
with the second, backward pass into the full 1D triangular kernel it tops
out at that kernel's own weight sum, 256; the vertical passes repeat the
same shape against those values, topping out at 16*256=4096. Both fit
uint16_t with no precision loss — the final `fertility` array still needs
32 bits, since its theoretical max (256*256=65536) is one past uint16_t's
range.

This is exact and safe, but measured negligible impact on Lattice/Maze
specifically (their cost is the sand-correction term, which these arrays
aren't part of). Worked through algebraically, that term reduces to
sum_d weight(d) * water(target+d) * sand(target-d) - a bilinear
cross-correlation between two different fields, not the single-field sum
the box-blur trick relies on, so it does not have an equivalent separable
speedup; only FFT-based convolution would change its asymptotic cost, and
Fertility::Field is shared with FertilityCalculator (real gameplay
fertility), so any such rewrite needs to preserve that caller's output
exactly too. Left as an open follow-up rather than attempted here.

Verified: full scons client build and scons -C test suite (198 cppunit
cases, including FertilityFieldTest) pass with no narrowing warnings.
MapGeneratorStudy succeeds across all 13 generators and produces
bit-identical per-tile terrain/resource hashes before and after on matching
seeds.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CkotX2daY8YTuFcRxFf5ba
…ipelago's hot loops

Continuing the same profiling pass as the previous two commits: Shattered
Coast (old-random) and Rugged Archipelago were the slowest and third-slowest
of all 13 generators, and neither bottleneck was in their own terrain logic.

GenerationContext::stream(name) looks up a named std::mt19937 in a
std::map<std::string, std::mt19937>, computing a hash over the name and
walking the tree on every call — cheap on its own, but both generators call
it by string literal from deep inside per-tile loops that run tens of
thousands of times per generation. Shattered Coast's simulateRandomMap
(itself invoked repeatedly per smoothing iteration to balance water/sand/
grass ratios) draws from "simulation" up to five times per tile across every
w*h-tile map it simulates; its terrain() draws from "terrain" the same way
across its own patchwork and smoothing passes. Rugged Archipelago's
island-growing and beach passes draw from "terrain" identically. In every
one of these loops the stream name is a compile-time constant that never
changes for the life of the function, so the repeated lookup was pure
overhead paid on every single draw.

Both functions now look their stream up once into a std::mt19937& at the
top and draw from that reference throughout, instead of calling
context.stream(name) again for every draw. This draws from the exact same
underlying generator in the exact same order, so it changes no random
number either function produces: MapGeneratorStudy's per-tile terrain/
resource hash is bit-identical on matching seeds before and after.

Verified: full scons client build and scons -C test suite (198 cppunit
cases) pass. MapGeneratorStudy succeeds across all 13 generators at 5 seeds
each. Measured 50% faster on Shattered Coast and 31% faster on Rugged
Archipelago end to end (interleaved before/after binaries, 60-100
generations per generator), with ~0% change on two unaffected generators
run the same way, ruling out this machine's run-to-run noise as the source
of the improvement.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CkotX2daY8YTuFcRxFf5ba
…n region splitting

Profiling identified Contested Commons, Concrete Islands and Isles as now the
slowest generators, dominated by the older Voronoi-style point-dispersion
code in shared/Regions.cpp and shared/Distances.cpp. Investigated with cache
behavior specifically in mind.

Exact fixes (same output, only how it's computed - verified by bit-identical
per-tile terrain/resource hashes on matching seeds, every generator):

- computeDistances' BFS used std::queue<int> (std::deque-backed, growing by
  separately heap-allocated blocks) even though every cell is enqueued at
  most once (the `side == 0` guard prevents a second push). A flat,
  preallocated w*h-sized array makes an exact FIFO out of sequential writes
  instead of block-to-block pointer chasing.
- splitUpArea's per-region frontier is inserted at a genuinely random
  position on purpose - that randomness is what gives the flood its organic
  shape, not an accident to remove - but std::vector<int> supports the
  identical random-position insert/erase via a contiguous memmove, without
  std::list's per-node heap allocation and pointer chasing on every single
  insert and on the std::advance that finds where to insert.
- adjustHeightmapFromPerlinNoise and computeAverageDistance are a pure
  per-cell transform and a commutative sum respectively, so nesting y
  outside x costs nothing and walks both the grid and HeightMap's own
  identically row-major _map array with their grain instead of across it.
- getAllPoints, getAllOtherPoints, findBorderPoints, and splitUpPoints's own
  two internal scans all walked a row-major grid as `for x { for y {...} }`
  - a full-row stride on every step - for no reason but habit, except two of
  these results (possible[n], startingPoints[n]) get indexed by a random
  draw, so a plain loop-order swap would silently pick a different point for
  the same seed. collectPointsColumnOrder gets both: a row-major counting
  pass sizes each output column, then a second row-major pass drops each
  point into its precomputed slot, landing every point in the exact x-major,
  y-minor order the original nested loop produced without ever striding
  across the grid to do it. splitUpPoints' single-pass "reset the candidate
  list on every strict improvement" site search reduces to the same set
  every time regardless of how it's computed (traced by hand, confirmed by
  the same hashes), so it is now an explicit two-pass max-then-collect using
  the same helper.

One further fix changes output for Contested Commons specifically:
splitUpPoints' PointSearch::WholeRegion mode (Contested Commons' own search,
not used by any other generator) scores every legal tile as a candidate
placement and keeps only a strict improvement, so whichever tied candidate
is reached earliest in scan order wins - the one shape here a loop-order
swap could not fix without changing anything. Measured directly across 7
seeds: 6 of 7 produce byte-identical maps regardless, and the one that
changed still converges to a placement of the same quality by the search's
own metric (already documented as bounded best-response, not a guaranteed
optimum). PointSearch::Local - used far more broadly, including as
swamp/river/islands/crater lakes' rare legacy fallback - keeps its exact
original scan order untouched: its window is 7x7, too small for traversal
order to matter, and touches far more generators than this fix was worth
risking. Contested Commons' revision bump follows in the next commit.

Verified: full scons client build and scons -C test suite (198 cppunit
cases) pass. MapGeneratorStudy succeeds across all 13 generators at 9 seeds
each. Measured (interleaved before/after binaries, repeated 3x to get past
this machine's run-to-run noise - an initial single-run reading overstated
the effect by more than 2x): Concrete Islands ~9% faster, Isles ~5-7%
faster, Contested Commons ~8-9% faster overall. Fjord Continent, which
touches none of this code, showed ~2% change over the same run, taken as
the noise floor rather than a real effect.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CkotX2daY8YTuFcRxFf5ba
The previous commit's PointSearch::WholeRegion traversal-order change can
change which exactly-tied candidate site wins a placement, so a given seed
can produce a different (equally valid, by the search's own metric) map
than before. Following this repository's existing convention for
generation-output changes, bump the revision so that is visible rather than
a silent side effect.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CkotX2daY8YTuFcRxFf5ba
genixpro and others added 17 commits September 13, 2026 19:54
FEEDBACK 2026-09-13 (third play): the tendrils were meant as "small
roads but way more frequent, extending from the outer perimeter of the
circle, inwards like 10 or 12 squares, maybe with a bit of meandering
... dozens of them, all the way around the perimeter, roughly evenly
spaced ... a default width of 2 cells of sand". Old town (revision 4)
now lays one wandering sand road every 8 tiles round the fields' cap
ring (some seventy on a 256 map), 10 to 13 tiles long and two tiles
wide, running in across the margin into the outer streets and notching
the outer blocks; the earlier plaza-to-ring routes are gone.

Verified on macOS arm64: golden table regenerated at revision 4 (264
rows, 0 failures), every Old town lobby cell on seeds 1 and 2, defaults
test, strict translation check, client build.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GpUW6Ny43aUA1EzThn6cYE
FEEDBACK 2026-09-13 (third play): "right idea on old town but let's do
1/2 the number of those inwards roads". Old town (revision 5) lays a
tendril every 16 tiles round the fields' cap ring instead of every 8:
some thirty-five on a 256 map.

Verified on macOS arm64: golden table regenerated at revision 5 (264
rows, 0 failures), every Old town lobby cell on seed 1, defaults test,
client build.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GpUW6Ny43aUA1EzThn6cYE
…Canals from play

FEEDBACK 2026-09-13 (first play of each), quoted in the code:

- Marches (25) and Patchwork (33) are removed: "just not working
  conceptually at all" and "the concept is failing". Their ids are
  retired, never reused; shared/Biomes stays in the toolkit.
- Fingerprint (revision 2): "looks very similar to everglades ... a
  higher wavelength has a nicer feel ... feels very empty ... home size
  needs to be larger". Wavelength 30 (was 20), homes of 18 (was 12),
  ambient layer more than doubled.
- Rain shadow (revision 2): "not enough water; ponds deeper and more
  connected; sand roadways through the passes; valleys feel empty;
  rivers to inland lakes; sand patches". Streams 5 deep and 20 long
  every 24; a sand road through every pass and 12 tiles into the
  valley; inland lakes (inland-lakes) with wandering rivers to the
  nearest stream; sand-patches over 6% of the valleys' grass.
- Polder (revision 2): "randomize the angle ... bases way too small,
  growth crowds them out ... 10x4 sand plots at 2.5x the players".
  row-angle is Random / Vertical / Horizontal / Diagonal (Random draws
  any angle and rounds to whole turn counts); villages of 14 in a
  two-tile sand ring; two and a half plots per colony (stampFarmPlot)
  spread through the rows away from the villages.
- Canals (revision 2): "remove the home pond; 1.5x bridges; 2x warp;
  every cell has its own little surprise". Dry home blocks, extra
  bridges 30, warp 80, and every other block dealt one of nine kinds:
  plain, lake, orchard, a 4x4 pad in a sand ring, two pads, quarry,
  woodlot, wheatfield, dune.

Verified on macOS arm64: golden table regenerated (248 rows, 0
failures), --sweep 0 failing combinations, defaults test, strict
translation check, client build.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GpUW6Ny43aUA1EzThn6cYE
A design's home sites come in a fixed order (farthest-point spreading starts
from cell 0, a lattice from its first row), so team 0 always started on the
same ground: top right of Old town, for one. Pipeline.h gains dealStarts, a
shuffle of a design's sites on its own stream, and every designed generator
calls it before anything is keyed by colony index: Fingerprint, Old growth,
Polder, Rain shadow, Canals, Anthill, Old town (homes and annexes together)
and Maze. Validators rebuilding the design from a fresh context get the same
deal. Revisions bumped and the golden table regenerated; a landscape check
covers the deal (a permutation, repeatable, not the identity for most seeds).

Also clears two warnings: an unused Torus alias in Anthill's validator and
Polder's diagonal row width constant, now used for the same value.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GpUW6Ny43aUA1EzThn6cYE
128 maps had no farms (two home ponds instead) because a farm squeezed
between the mountains kept losing a strip to its own coast walls: at
128x256 a strip joined to its field through an isthmus three tiles wide
was sealed off when the walls of the coasts either side met across it,
so the farm-reach check failed and the lobby retried a quarter of seeds.
growFarmFields now keeps only the ground whose eroded core is joined to
the home's core (two cores that do not touch can still overlap once grown
back, through a waist the walls then close); the opening radius is a
parameter with the old default, so Carousel is byte-identical. The size
floor and the pond fallback go; 12 seeds pass at every 128-based cell.
Revision 2, golden rows regenerated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GpUW6Ny43aUA1EzThn6cYE
Third play: "the farms on switchback still don't seem to expand fully
into the available area ... just ensure that the farms always grow to
fill the available space" and "get rid of the water channels in between
bases, similar to how we had to modify carousel". The fields kept three
tiles of water from each other and from the mountains; on a 256x128 map
that gap, cut through fields grown into fingers, took two fifths of the
open sea and the opening most of the rest. Now the fields grow with no
gap, every tile of sea is filled to its nearest field (fillToNearest with
the whole map as reach), and a single line of stone stands on every
border between colonies (labelBorders), as on Carousel; the farms' rim
drops to 3 since no coast lies beside them. Sea within a level-3 tower's
range of the plateau becomes rock first, so the mountains' inner ends
join round the plateau and no farm reaches it. Revision 3, 12 seeds pass
at every cell from 128x128/2 to 256x256/8, golden rows regenerated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GpUW6Ny43aUA1EzThn6cYE
Third play: "we need to find a more reliable mechanism to divide the
territory between players for amphitheatre ... wildly unsmooth borders.
lets come up with something fair but also simple and reliable." The
territories were grown a tile at a time by the smallest first with noise
in the cost for a border-roughness control, then smoothed by a majority
filter; the turn-taking grows fingers along every border and smoothing
left them ragged. Territories.h gains balancedTerritories: every tile
outside the wall goes to the colony whose ramp mouth is nearest by
squared distance less a weight, a power diagram whose borders are
straight lines, and the weights are tuned round by round (a step sized
from the shortfall over the border, undone and halved when it made
things worse) until the areas are within 1%. Each colony keeps a disc
round its mouth whatever the balance, so a shifted border never crosses
the way in. Steps and distance-from-arcs were tried first and swing
instead of settling (documented in the header). The roughness control
goes, with its key in all 34 tables. The validator now targets the pit
tile each ramp can reach rather than the very middle, which the orchard
ringed with fruit on some seeds (128x128/4 seed 1 was a golden "failed").
Revision 2; 12 seeds pass at every cell from 128/2 to 256/8.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GpUW6Ny43aUA1EzThn6cYE
Second play: "too many of them are empty of resources. like with the
4x4 squares - those squares are to hold farming buildings. they only
matter if there are resources elsewhere on that island outside the sand
square. can we also add some types that are more like forts? with some
stone that guides enemies into a kill zone or something?" and "a few of
them should be intended for building buildings on of course. but too
many of them are boring right now." The pad kinds are now homesteads
and hamlets with a wheat and a wood clump beside every pad; plain
blocks fall from 24 in 100 to 6; and five kinds built of stone or water
join, each dealt a facing: a fort (a 13-tile square of wall round a pad
with one three-tile gate), a bastion (four corner walls, an opening in
every side), a funnel (two walls in a V onto a three-tile gap, the pad
behind it), a chicane (two staggered walls with a corridor between) and
a moat (a ring of water round an islet with a pad and one sand
causeway). Walls are drawn in the block's frame only where the warp
left pure grass clear of water, and a block whose walls would cut its
land or a bridge off gets none (blockWalls). Every pad and built kind
carries the ambient fields too (15/10 in 100 of fertile tiles), a tile
clear of the walls. Validator checks every wall's stone and every moat.
Revision 4; 12 seeds pass at 128/2 to 512/4.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GpUW6Ny43aUA1EzThn6cYE
A self-review of the generators built and revised this week, with the
golden table unchanged (248 rows, 0 failures) as the proof that no map
moved. Boilerplate every designed generator copied goes into the
toolkit: designFailure<design> (the registry's request check), and in
Pipeline.h settleRoundColonies with homeGrassMask (round homes settled
on their own grass) and homePondMissing (the validators' pond check);
nearestSiteDistance (Orbits.h) replaces four copies of the same loop
and tilePoint (Tessellation.h) two. Anthill keeps one list of chamber
kinds (ChamberKind) instead of computing it in design and again in
generate. Stale comments corrected: Switchbacks' pre-fill farm paragraph
and "water between mountains" wording, Old town's wall (removed after
the first play), Rain shadow's stream sizes, Old growth's pool-ring
distance, Canals' "market blocks" that never existed, Polder's and
Anthill's superseded defaults, Amphitheatre's removed roughness control;
debug includes dropped. The four control keys left by the deleted
Marches and Patchwork generators go from all 32 tables that held them,
and the docs name the new helpers.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GpUW6Ny43aUA1EzThn6cYE
The nine landscapes and three walled arenas registered 47 strings that
were left as English fallbacks in all 32 catalogs, and CI's translation
gate fails on the first fallback it sees. Every one now has a translation,
aligned with each catalog's existing terms for ridges, passes, homes and
resources. Where a catalog's own word is spelled as the English is (Polder
in the Germanic and most Slavic catalogs, Coral in es/pt/ro, Spiral in
dk/id/sv/tr, Plazas in es, Canals in ca, Grain in fr, Diagonal in de/id)
the word is listed as shared vocabulary in
test/translation_shared_values.json rather than reworded.

Seven keys no generator refers to any more (Marches, March width,
Patchwork, Orchards, Straight, Gates and the old home-size failure) are
removed from texts.keys.txt and every catalog.

texts.br.txt is Brazilian Portuguese; an earlier pass wrote about fifty of
this branch's entries (Coral, Spider web and the walled arenas) in Breton.
They are rewritten in Portuguese, following texts.pt.txt with Brazilian
forms (colônia, trilha, fazenda, menor).

Verified: data/check_translations.py --strict, test/test_translations.py,
test/test_font_coverage.py and test/test_text_area_layout.py all pass.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GpUW6Ny43aUA1EzThn6cYE
… style

Four items from the review of b86c59b:

- Tessellation.cpp named a vector `near`, a legacy windef.h macro that
  expands to nothing on mingw and broke the Windows build. Renamed
  `nearby`.
- makeHomogenMap, controlSand and smoothResources had been folded into
  MapTerrain.cpp, which test/SConstruct links against a stripped
  server-mode libgag with no globalContainer, so the unit-test build no
  longer linked. They are back in their own translation unit,
  src/map/generator/MapHomogen.cpp, listed with the generator sources;
  nothing outside the generators and their tests calls them.
- Stone Highlands' nearbyValleys returned before its sort when a fourth
  valley was in the window, so a valley pair could key the anyPair map in
  either order and the fallback pass-carving saw split candidate lists.
  The helper now keeps a sorted three-slot set and always returns it
  sorted. The same shape removes the std::sort over a runtime-bounded
  raw array that GCC 15 flagged with -Warray-bounds. The generator's
  revision moves to 3; the golden rows for the sampled seeds are
  unchanged, so the fix only bites where the fallback ran.
- Glob2.cpp had two includes above its SPDX header and three
  space-indented lines in a tab-indented file.

Verified: scons release=1 builds with no warnings; MapGeneratorDefaultsTest
passes; MapGeneratorGoldenTest passes after --update (only the Stone
Highlands revision column changed); test/MapExploredAreaSaveLoadTest builds
and passes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GpUW6Ny43aUA1EzThn6cYE
Brings in the CORN -> WHEAT identifier rename (#269) and the status-bar
clamp (#194). The rename is applied to every file this branch adds or
changes: 158 identifiers and comments across the generators, the shared
toolkit, the generator tests and tools/MenuColonyHarness.cpp, so the
branch has no CORN left outside the cortex-ml Python tools master also
left alone. The five legacy generator files master renamed inside were
already deleted here; FertilityCalculator.cpp, MapHomogen.cpp and
MapGeneratorStudy.cpp keep this branch's versions with the rename applied.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GpUW6Ny43aUA1EzThn6cYE
…ences

- MapGeneratorGoldenTest gains --require-rows: the same check, but a
  platform with no rows in the table fails instead of reporting and
  passing. The plain check keeps passing on a rowless platform so a new
  machine can run it before its rows exist. CI will pass the flag once the
  linux-x86_64 rows are committed; until then the workflow runs --print
  before the check, so the rows a platform would record are in the log
  even when the check fails.
- CustomGamePreferences bounded the legacy riverDiameter slot at 100, and
  Old growth's lake size, which shares that slot, reaches 160: a saved
  lobby with a big lake failed to load and fell back to defaults, and
  CustomGameSetupHarness's whole-range round trip caught it. The envelope
  is now 0-160.

Verified: CustomGameSetupHarness passes in default, preferences-write and
preferences-read modes; MapGeneratorGoldenTest passes with and without
--require-rows on macos-arm64; --sweep reports 0 failing combinations;
test/TestsRunner and every test/ harness pass; MenuColonyHarness check and
navigation pass.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GpUW6Ny43aUA1EzThn6cYE
The 248 linux-x86_64 rows come from the --print output of CI run
34839155040 on c6fb9d5; the ubuntu 22.04 (g++ 11) and 24.04 (g++ 13)
jobs printed identical rows, so one platform tag covers both. 156 of the
rows match macos-arm64 hash for hash; the other 92 are the generators
that go through floating point (Fjord continent, Watershed at 512, Ring
world and others), which is the per-platform caveat the table was
designed around.

With the rows in, the workflow runs the check as --require-rows, so a
Linux job can no longer pass this step with nothing to compare, and the
test README and framework reference say where a new platform's rows
come from.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GpUW6Ny43aUA1EzThn6cYE
windef.h defines both as empty macros, and the Windows job stopped at
Territories.h's `const int far` once Tessellation.cpp compiled. Every
remaining use as an identifier is renamed: farthest in Morphology.cpp
and Territories.h, clearance for the distance-to-keep-clear vectors in
Old growth, Old town and Polder, and nearby/distant in the two test
headers. Pure renames; the golden table is unchanged and the defaults
test passes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GpUW6Ny43aUA1EzThn6cYE
The Windows job failed SavegameSafetyHarness's check that a save leaves
the sync RNG untouched, with the same harness and save code master
passes there; the only change to the RNG on this branch is that
`randomGenerator` became `thread_local`. A thread_local with a
constructor that other translation units name directly is initialised
through a weak per-unit wrapper, which mingw's emulated TLS is the one
platform to get wrong. The engine is now a function-local thread_local
behind `syncRandEngine()`, initialised in one place in Utilities.cpp,
and every former use of the object (save and load, the menu colony's
swap, the generation service's scope, the harnesses) calls the accessor.
Same semantics everywhere else: golden maps, the defaults test, the
savegame and trapped-unit harnesses and the setup harness all pass
unchanged on macOS.

The savegame harness now prints both RNG states when they differ
instead of a bare assert, so the next platform that disagrees says how.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GpUW6Ny43aUA1EzThn6cYE
@genixpro

Copy link
Copy Markdown
Contributor Author

Review response, at ab95800c2

Thanks for the thorough pass. Every item is addressed on this head; master (#194, #269, #181, #278) is merged in as well, with the CORN → WHEAT rename applied across this branch. What changed, in your numbering:

Blocking

  1. CI red. The translation gate was the nine landscapes and three arenas registering 47 strings that no catalog had: every one is translated in all 32 catalogs (850ce2c), aligned with each catalog's existing terms for ridges, passes, homes and resources. Where a catalog's own word is spelled as the English is (Polder in the Germanic and most Slavic catalogs, Coral in es/pt/ro, Spiral in dk/id/sv/tr, Plazas in es, Canals in ca, Grain in fr, Diagonal in de/id) it is listed as shared vocabulary rather than reworded. Seven keys no generator refers to any more are removed. While in there: texts.br.txt is Brazilian Portuguese and an earlier pass had written about fifty of this branch's entries in Breton; they are rewritten. The mingw break is the near vector in Tessellation.cpp, renamed nearby (953fe43); once that compiled, Territories.h stopped on const int far, so every remaining near/far identifier in the generators and their test headers is renamed too (1617f7e).
  2. test/ link. makeHomogenMap, controlSand and smoothResources are back in their own translation unit, src/map/generator/MapHomogen.cpp, listed with the generator sources; MapTerrain.cpp no longer pulls globalContainer into the stripped link surface. cd test && scons MapExploredAreaSaveLoadTest builds and passes again, and the whole test/ suite (TestsRunner plus every harness) is green locally.
  3. Golden gate. test/map-generator-golden.txt now carries linux-x86_64 rows, taken from this CI run's --print output, and the check gained --require-rows: the same comparison, but a platform with no rows fails instead of reporting and passing. CI passes that flag, so the Linux jobs cannot pass this step vacuously any more; the plain check keeps passing on a rowless machine so a new platform can still be bootstrapped. The workflow prints the rows before it checks, so the next platform's rows are in the log even when the check fails. The ubuntu 22.04 (g++ 11) and 24.04 (g++ 13) jobs printed identical rows, so one platform tag covers both; 156 of the 248 rows match macOS hash for hash and the other 92 are the generators that go through floating point (Fjord continent, Watershed at 512, Ring world and others), which is the per-platform caveat the table was designed around.

Worth fixing

  1. nearbyValleys. The helper keeps a sorted three-slot set and always returns it sorted, so a valley pair keys anyPair in one order. Stone highlands moves to revision 3. The golden rows for the sampled seeds are unchanged, so the fix only bites where the fallback ran.
  2. -Warray-bounds. The same rewrite removes the std::sort over a runtime-bounded raw array; there is no runtime count for GCC to lose track of any more.
  3. Glob2.cpp. The two includes are in the client include block under the header, and the three space-indented lines are tabs.

Design calls

  1. thread_local sync RNG. This one bit, though not where either of us looked. Once the Windows job compiled, SavegameSafetyHarness failed there on its check that a save leaves the sync RNG untouched, with the same harness and save code master passes on the same toolchain (GCC 16.2, Boost 1.92, MSYS2). The one RNG change on this branch was extern thread_local boost::mt19937 randomGenerator: a thread_local with a constructor that other translation units name directly is initialised through a weak per-unit wrapper, and mingw's emulated TLS is the platform that gets it wrong. The engine now lives behind syncRandEngine(), a function-local thread_local initialised in one place in Utilities.cpp, and every former use of the object calls the accessor (ab95800). With that the Windows savegame step passes, and Linux and macOS behave exactly as before (golden table, defaults test, savegame, trapped-unit and setup harnesses all unchanged). Reproduced outside CI too, on a Windows Server 2022 VM with the same MSYS2 toolchain: SavegameSafetyHarness.exe fails at 1617f7e with that exact assertion and passes at the same tree plus this patch. The harness also prints both RNG states now instead of a bare assert. On the invariant itself: the simulation still runs on the main thread only and the previewer threads never touch a live Game; a debug-time thread check on the simulation step is a reasonable follow-up for its own PR.
  2. Ids 7 and 8 are a substitution, as you read it: Shattered coast and Rugged archipelago are the old generators reworked under the same ids and keys, so an old lobby preference selects the reworked landscape. Documented in MAP_GENERATOR_FRAMEWORK.md.
  3. Preferences are forward-compatible only, yes. An older binary rejects the new options section and falls back to defaults, which is the recovery path the harness tests.

Two more things the fixes turned up

  • Old growth's lake size did not survive a lobby restart. Its range reaches 160 and it shares the legacy riverDiameter slot, bounded at 100, so a saved lobby with a big lake failed to load and fell back to defaults. CustomGameSetupHarness's whole-range round trip caught it once the harness could run again; the envelope is 0-160 (c6fb9d5).
  • The lobby does gate the colony counts a generator refuses. CustomGameScreen shows setup.validation() as the message and only starts when it is empty, so Symmetric arena at 3, 6 or 12 colonies and Ring world at 12 are refused with their translated reason rather than failing at generation. Concrete islands at 12 colonies is still marginal in the sweep (4/5 at 256, 2/3 at 512), as you found; the lobby's best of five absorbs it, and tightening its placement is on the follow-up list.

Fairness tournament

Two full-rotation runs on this head (tools/map_fairness_tournament.py, Nicowar in every slot, 128×128, 4 colonies, best of 5 lobby rolls per seed, tick cap 90,000; every rotation of team numbers over starts is played, so wins by start carry no team-index effect and wins by team index carry no map effect):

  • baseline (Symmetric arena only, six maps, 96 games, 69 decided by elimination, 2 of 2 re-run games reproduced exactly): position bias 0.0 pp against a fair-map floor of 8.7, no map biased after Benjamini-Hochberg, wins by colony index 26 / 15 / 29 / 26 (p 0.17). Wins by team index 25 / 18 / 18 / 35 (p 0.053), team 3 at 36.5% [27.5%, 46.4%]: the engine's processing-order effect sits at the edge of detectability on a map with nothing else in it, and is the thing to keep measuring.
  • smoke (Symmetric arena, Contested commons, Watershed, Crater lakes; three maps each, 96 games, 82 decided): the same numbers as the 2026-09-12 run in FAIRNESS_TOURNAMENT.md, game for game. Team index pooled over all four generators 28 / 21 / 23 / 24 (p 0.80). Symmetric arena shows no start bias (0.0 pp, floor 14.4). The three asymmetric generators do, per map rather than per colony index: Contested commons 35 pp with 3 of 3 maps biased, Watershed 33 pp with 2 of 3, Crater lakes 29 pp with 2 of 3, and the best start on those maps wins 2.5 to 3.5 times its fair share whichever team plays it. The start scorer only partly predicts which (rho 0.59 on Contested commons, 0.68 on Crater lakes, none on Watershed).

The standard preset (every generator, 2,160 games, about 5.5 hours at 3 jobs) is not part of this PR; its generator list predates the landscapes and it belongs with the per-generator tuning that follows this merge. Reports and CSVs for both runs are under artifacts/map-fairness/ locally and available on request.

Verification on this head

  • CI: run 34844458453 is green on linux (ubuntu:22.04), linux (ubuntu:24.04) and windows (mingw-w64), every step included: translations, the full build, the golden check with --require-rows against the committed Linux rows, --sweep, the setup harness, the test/ suite, the savegame harness on Windows, and the menu-colony navigation test.
  • Locally (macOS arm64, release): full build with no warnings in this branch's files; MapGeneratorDefaultsTest all groups; MapGeneratorGoldenTest 248 rows, 0 failures, with and without --require-rows; --sweep 0 failing combinations over 32 generators; CustomGameSetupHarness in default, preferences-write and preferences-read modes; MenuColonyHarness check and navigation; test/TestsRunner and every test/ harness; check_translations.py --strict, test_translations.py, test_font_coverage.py, test_text_area_layout.py.

On playtesting: I have been playing these maps meticulously as they were built (City states over four iterations, Tidal flats three, the nine landscapes through six feedback rounds, the arenas three), and I am not done. The plan is to merge this as the framework plus the current catalog and keep tuning individual generators in smaller follow-up PRs, where each change is one map and one revision bump rather than forty thousand lines.

🤖 Generated with Claude Code

https://claude.ai/code/session_01GpUW6Ny43aUA1EzThn6cYE

genixpro and others added 2 commits September 14, 2026 10:54
Play verdicts on the branch, each explained in the generator's header:

- Tidal flats: six oases and eight sandbars per wedge by default (were three),
  drawn smaller and rounder with tighter gaps, so crowded wedges seat more of
  them; the smallest oasis keeps its pond in a ring of wheat.
- Carousel: the sea inside the ring stays a lagoon with algae, the inward farm
  and the home kit are gone, and every lane or court on the lagoon keeps a
  5-tile margin that is all stone (laneBand): on the 3-tile margin a diagonal
  spoke lost every grass tile down its middle to the beach and its seal, which
  cut colonies off from the plaza.
- City states: an archipelago of round islets in the strait and the channels,
  each with the farms' 10x4 building plot and a small prize, two either side of
  every causeway on a four-colony map and more on a longer arc; drawn on the
  map about frame-designed middles so a bowed channel does not shear them, and
  a slot that would break its 3-tile moat in any wedge is left out of every
  wedge. The Barrens home layout is gone. Commons 45%, strait 11%.
- Standard farms (Farmland): the sand bridges run clean across the whole farm,
  crop rows and water alike.
- Amphitheatre: starting towers stand against the arena's outer wall within 18
  steps of the colony's own ramp, covering the arena, not along the borders.
- Starting tower level defaults to 1 on Carousel, Amphitheatre, Switchbacks and
  Canals.
- Switchbacks, Carousel and Polder homes have no wheat and wood kit; Polder's
  wood share is 8% (was 15%).
- Rain shadow: streams 7 deep (were 5), pass roads 18 tiles into each valley
  (were 12), ending in a lane of sand that joins the streams' beaches.
- Canals: a `block-shape` choice of squares or hexagons, as on Maze.
- Maze and Stone highlands amounts are percentages like every other landscape;
  the resource-amount audit is recorded in the framework doc.
- The catalog order was shuffled once, so the list carries no bias towards the
  landscapes written first; the lobby and editor open on its first entry.

Revisions bumped for every generator whose maps changed (Old town through the
shared farm bridges); golden rows regenerated for macos-arm64 and linux-x86_64
(therig, gcc), --sweep 0 failing combinations. The saved-preferences bound for
extra islands covers Tidal flats' new range. Docs updated in
docs/map-generators/MAP_GENERATOR_FRAMEWORK.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GpUW6Ny43aUA1EzThn6cYE
…down

- The custom game screen opens on a random map at four colonies when nothing
  is saved; the premade library preselects FourSquares1 the first time it is
  opened. A saved lobby still restores every setting, generator options
  included (verified by the preferences harness modes).
- Reset to defaults moves to the top of the map column, right under the
  landscape chooser, with Random parameters beside it: every one of the
  landscape's controls drawn at random (GenerationRequest::randomizeControls),
  a set the generator refuses redrawn on the spot and one the world refuses
  redrawn when the preview's candidates come back empty, up to six times, so
  the first set that generates a valid map is the one shown.
- The landscape picker gains Randomize parameters beside Regenerate all, with
  the same redraw rule per tile (LandscapePreviewer::reroll), and Use hands the
  lobby the parameters the shown map was rolled with as well as its seed.
- Under a generated map's preview: its fairness and score, and an (i) that
  opens StartQualityScreen, the per-colony breakdown of what was measured, how
  each factor scored, the weights and the totals.
- Labels that do not fit a compact button take the small font.
- 13 new UI strings in all 32 catalogs; MenuColonyHarness drives the preview
  before reading the map header; CustomGameSetupHarness covers the new buttons,
  the breakdown screen and the picker's randomize. Docs updated in
  docs/custom-game-setup/README.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GpUW6Ny43aUA1EzThn6cYE
@genixpro

Copy link
Copy Markdown
Contributor Author

Play-note batch pushed as 81622ca2a (generators) and 65ae97262 (lobby); the PR description has the full list under "Play-note batch (2026-09-14)".

Screens and example maps: https://claude.ai/code/artifact/a10eb8c8-8dc2-49d6-9f73-f6645ff03755 (the compact lobby with Reset to defaults and Random parameters under the landscape chooser and the fairness/score line with its (i); the start quality breakdown; the picker after Randomize parameters; City states 256/4 with its archipelago; Carousel 256/3 with its lagoon and stone-banded spokes; Tidal flats 256/8 with six oases and eight sandbars per wedge).

To reproduce locally: build/src/CustomGameSetupHarness artifacts/custom-game/compact writes the lobby captures (random-parameters-640.bmp, start-quality.bmp, landscape-picker-randomized.bmp), and python3 tools/render_map.py one city-states --seed 1 --size 256 --set teams=4 --out cs.png (likewise carousel with teams=3, tidal-flats with teams=8) renders the maps.

Verification on this head, macOS arm64 release: MapGeneratorDefaultsTest; golden 248 rows, 0 failures with --require-rows, linux-x86_64 rows regenerated on a Linux x86-64 box (gcc); --sweep 0 failing combinations; CustomGameSetupHarness headless with captures plus both preferences modes; MenuColonyHarness check; all four translation checks.

Two things worth a maintainer's eye in play: Carousel's spokes now cross a lagoon between bands of stone, and the catalog shuffle makes Fingerprint the lobby's opening landscape.

…set to defaults

- City states: the islets move out of the strait into the sea beyond the design
  circle, where the torus wraps (FEEDBACK 2026-09-14: "in the area outside the
  circle ... in like the empty space where the torus wraps", and bigger, since
  the plot "is taking up too much of the space"). One islet on the wrap point,
  then `islands` rings of eight, sixteen and so on at equal angles with the
  map's own four-fold symmetry (a rectangle's two points across the wrap on
  each axis get the same rings), radius 11, each with its 10x4 plot and a
  small prize; an islet whose disc and three-tile moat are not all sea is left
  out with its mirror images. The commons and strait defaults return to 55 and
  4 percent now that the strait holds nothing. Revision 9; golden rows
  regenerated for both platforms, --sweep 0 failing combinations.
- Landscape picker: Reset to defaults beside Randomize parameters puts every
  landscape back on its registered controls at the sheet's size and colony
  count and rolls the sheet again; harness coverage and docs.
- Lobby: switching to the premade library drops random-map candidates still
  rolling, which would otherwise come back and replace the premade choice now
  that the lobby opens on a random map.
- Harness: the random-parameters check gives the preview its redraw rounds
  (a refused random set aborted the visual run on ubuntu 24.04), and the SDL
  flow starts from the premade library as it was written to.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GpUW6Ny43aUA1EzThn6cYE
@genixpro

Copy link
Copy Markdown
Contributor Author

Follow-up 459093f3d from the last play notes: City states' archipelago now lies in the sea outside the design circle, where the torus wraps (an islet on the wrap point, a ring of eight and four more along the axes on a 256 map, radius 11 so the 10x4 plot leaves room to build), with the commons and strait back at 55 and 4 percent; the landscape picker gains Reset to defaults beside Randomize parameters; switching to the premade library while the first random preview is still rolling no longer lets that preview replace the premade choice. The results page linked above shows the new map.

The ubuntu 24.04 failure on the previous run was the visual harness: a random parameter set the world refused is redrawn by the preview itself, and the check only gave it one round. It now gives it the redraw rounds. Locally on macOS arm64: golden 248 rows with 0 failures under --require-rows (Linux rows regenerated on the x86-64 box), --sweep 0 failing combinations, City states 5/5 seeds at 128 and 256 for 2 to 8 colonies, and the harness's visual, preferences-write and preferences-read modes.

The xvfb run wrote only to artifacts/custom-game-ci/ui.log, which the job
does not upload, so an assertion there left nothing to read but the exit
code. The generation chatter is filtered and the rest goes to both places;
the shell's pipefail keeps a failure failing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GpUW6Ny43aUA1EzThn6cYE
@genixpro
genixpro merged commit 4c16a84 into master Sep 14, 2026
3 checks passed
@genixpro
genixpro deleted the codex/modular-map-generators branch September 14, 2026 16:24
genixpro added a commit that referenced this pull request Sep 14, 2026
The size controls are shared since #240, so lowering their minimum put 32 x 32
in the lobby too, where most landscapes cannot seat colonies and the setup
harness's size step lands on an invalid map. The shared controls keep 64 as
the smallest size; the editor's new-map screen and request validation use
editorSizeControl, which reaches 32, and switching landscapes keeps the size.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WePqccHKmAjY6cwoqEcvBE
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants